Skip to content

Add chain syncing via compact block filters (BIP 157) - #828

Open
febyeji wants to merge 11 commits into
lightningdevkit:mainfrom
febyeji:add-cbf-chain-source
Open

febyeji wants to merge 11 commits into
lightningdevkit:mainfrom
febyeji:add-cbf-chain-source

Conversation

@febyeji

@febyeji febyeji commented Mar 15, 2026 •

Copy link
Copy Markdown
Contributor

This PR adds a new compact block filter chain source (via kyoto) to ldk-node. Let's go through its features.

Design notes

Contrary to other chain sources, we use a push-based model: we don't proactively request data, but rather listen
for events and process them.

Kyoto

Kyoto is a standalone node which comes in the form of a library. It manages peers and provides users with compact
block filters as well as the ability to request blocks on demand.

Getting data from kyoto is done via a channel which emits events (e.g. IndexedFilter which means we received a new
compact block filter).

Operation

In broad terms we operate in the following way:

  1. On start, build and run the kyoto node
  2. Listen for kyoto events
  3. Process kyoto events and respectively apply needed operations (e.g. download full block to apply it).

There is no concept of "active syncing": when the chain source runs, it always processes new events and applies them.
Method sync_wallet waits until the application of all blocks up to the known tip.

Node aliveness

As we are responsible for always having an operational node, we have its runtime status, and if it goes down by any
reason, we try to restart the kyoto node. If something went wrong and after MAX_RESTART_RETRIES=5 the node still died,
we give up.

Scripts

CBFChainSource tracks watched scripts provided from the onchain wallet as well as the lightning wallet.

We need to track two types of scripts: ones provided from the onchain wallet (e.g. to fund the lightning node) and
"LDK-scripts", exposed and needed to be tracked during channel operation (e.g. channel force-close transactions). The
former scripts are revealed via bdk wallet. We don't maintain a CBFChainSource-related copy of them, rather we just ask
bdk wallet each time we need them. This is done to have a single source of truth.

Thus, registered_scripts are LDK-revealed scripts.

Block applicator

This entity applies blocks to the state of the node. (Perhaps its title can be changed to something more concise.)

It runs as its own background task, decoupled from kyoto event processing via an unbounded channel of ChainOps:

  • ConnectFull — filter matched, apply the downloaded full block (block_connected)
  • ConnectFiltered — filter didn't match, apply header only (filtered_block_connected with no transactions)
  • Disconnect — reorg, disconnect back to the fork point
  • Synced — kyoto reported FiltersSynced, i.e. we caught up to the network tip
  • Failed — an unrecoverable error occurred while producing ops (surfaces to sync_wallets waiters)

For applying blocks we reuse bitcoind's ChainListener, which fans each connect/disconnect out to the onchain
wallet, channel manager, chain monitor and output sweeper.

process_kyoto_events is a single sequential loop: for a matched filter it awaits the full block fetch (with
retries/timeout) inline, and for an unmatched one it awaits the header fetch inline, before moving on to the next
kyoto event — this is what ensures ops are produced in order. The block applicator then just consumes those ops
in order and applies them. As a safety net it also tracks the next expected height itself and skips any
out-of-sequence op (e.g. duplicates, or stale ops produced before a reorg); a Disconnect rewinds the expected
height to the fork point.

Besides applying blocks, the applicator is the single writer of CbfSyncState: it advances applied_tip /
synced_to_tip (which sync_wallets waits on) and persists the node's wallet-sync timestamps once the tip is
reached. When the native kyoto fee source is used, it also opportunistically caches the coinbase-derived fee rate
of every full block it applies, so the fee estimator doesn't have to download those blocks again.

There is room for improvement: we could download full blocks in parallel (e.g. using
https://docs.rs/futures/latest/futures/stream/struct.FuturesOrdered.html), which would speed up initial sync.

We could do without this additional entity, by just processing ops in place, but we stick to this design for
clarity.

Fee source

As we don't have mempool with CBF chain source, we cannot properly estimate the transaction fees.
There are three options for a fee source provider (FeeSource):

  • native kyoto estimation via extrapolation from recent blocks' fees
  • esplora
  • electrum

Using esplora or electrum just for the fees is privacy-preserving and acceptable.
Using native extrapolation is a fallback method, because it has two flaws:

  • we need to download all blocks, which defeats kyoto's bandwidth advantage
  • the estimation is still rough, as we have no mempool

Also, recently fetched blocks (when we need to download a full block anyway) are cached so they won't be downloaded
twice during a fee update.

Reorganizations

On reorgs kyoto emits events about disconnected blocks and we disconnect them accordingly.

CbfSyncState

We need that struct to distinguish between last block that kyoto has and the last block we have applied locally, these
may be different blocks. Also used to understand whether we processed everything up to kyoto's tip.

Testing

We tried to adhere to already existing tests, as they cover the vital node operation scenarios. Though some of the tests
are not meant for kyoto (for example tests related to mempool, 0-conf channels, RBF).

As we want to test exclusively the new chain source in this PR, we've added an environment variable overriding the
random chain source choice, it can be used on the following way:

LDK_TEST_CHAIN_SOURCE=cbf cargo test --test integration_tests_rust

Other available options: esplora, electrum, bitcoind-rpc, bitcoind-rest.

Also please note sometimes in test scenarios kyoto bans bitcoind because of this bug:
bitcoin/bitcoin#35354.

Previously tests assumed that the chain source and the mining node are the same entity, i.e. there is no gossip delay.
This is not the case with the kyoto node, that's why one might experience a race condition due to gossip propagation
(we do something, like mine a block with a needed transaction, then it is correctly shown in esplora/electrum/bitcoind
and we try to invoke some action, but the new block has not yet propagated to the kyoto node); the situation is
worsened by the fact that GitHub's CI runners are slower than local machines, and a simple time-based wait does not
work well to circumvent the propagation delay. That's why many functions in the tests have additional waiting for
propagation.

Tests not comptible with the compact block filter node:

  • channel_full_cycle_0conf,
  • channel_full_cycle_0conf_0reserve
  • onchain_send_receive
  • onchain_fee_bump_rbf,
  • onchain_fee_bump_rbf_respects_anchor_reserve

History

We switched to bitcoind's sync model (poll_and_update_listener vs sync_onchain_wallet and sync_lightning_wallet).

We tried to follow the patterns for already existing chain sources, more precisely we had distinct sync_onchain_wallet
and sync_lightning_wallet (dictated by transaction-based syncing sources) functions however it is suboptimal, because
we might fetch the same block twice.

@ldk-reviews-bot

ldk-reviews-bot commented Mar 15, 2026 •

Copy link
Copy Markdown

👋 I see @tankyleo was un-assigned.
If you'd like another reviewer assignment, please click here.

@febyeji
febyeji force-pushed the add-cbf-chain-source branch 5 times, most recently from 6e39d0d to 86e88ce Compare March 16, 2026 06:35
@febyeji
febyeji marked this pull request as ready for review March 16, 2026 06:53
@ldk-reviews-bot
ldk-reviews-bot requested a review from tnull March 16, 2026 06:53
@febyeji
febyeji force-pushed the add-cbf-chain-source branch from 86e88ce to 3de4777 Compare March 16, 2026 11:44
@febyeji
febyeji marked this pull request as draft March 16, 2026 11:49
@febyeji
febyeji marked this pull request as ready for review March 16, 2026 15:47
@febyeji
febyeji force-pushed the add-cbf-chain-source branch from 3de4777 to 9692ff4 Compare March 17, 2026 14:27
Comment thread src/chain/cbf.rs Outdated
Comment thread src/chain/cbf.rs Outdated
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@febyeji
febyeji marked this pull request as draft March 19, 2026 14:48
@febyeji
febyeji force-pushed the add-cbf-chain-source branch 6 times, most recently from 388d7d2 to fdf6710 Compare March 26, 2026 11:02
@tnull
tnull removed their request for review March 27, 2026 07:42
@tnull

tnull commented Mar 27, 2026

Copy link
Copy Markdown
Collaborator

@febyeji @randomlogin Please let me know when you consider merging of the two PRs completed and this is ready for a first round of review!

Comment thread src/chain/cbf.rs Outdated
}

/// Broadcast a package of transactions via the P2P network.
pub(crate) async fn process_broadcast_package(&self, package: Vec<Transaction>) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bip157 version 0.5.0 includes a Package type that may be used here.

@tnull do I understand correctly that Vec<Transaction> is used so the traits are forward compatible for arbitrary package lengths? My crate only supports packages of size 1 and 2, as per the current Bitcoin Core policy.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, well, kinda. LDK's trait uses a &[&Transaction] I believe. Generally we prefer to leave any transaction/package validation to Core via. the chain source. Given bip157 is the chain source, it should be fine to use from_vec and log an error if that fails for whatever reason.

@febyeji
febyeji force-pushed the add-cbf-chain-source branch 3 times, most recently from 8a03367 to 187b18d Compare April 24, 2026 01:46
Comment thread src/chain/cbf.rs
// Bound the download so an unresponsive peer can't park the fetch forever,
// then flatten the three error layers (timeout / receiver dropped / fetch
// error) into a single reason so the retry-or-fail decision is written once.
let fetched = tokio::time::timeout(

@rustaceanrob rustaceanrob Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will stop further filter checks even if there are filters available to check, which appears rather unfortunate. We already have a structure here BlockApplicator that is asynchronous, so I am wondering why we don't await the network bound task over there and let this task continue checking filters.

I haven't fully implemented proper error propagation, but it would look something like this:

diff
diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs
index 7baa4a0..6dd7ddd 100644
--- a/src/chain/cbf.rs
+++ b/src/chain/cbf.rs
@@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex};
 use std::time::{Duration, SystemTime, UNIX_EPOCH};

 use bip157::chain::ChainState;
+use bip157::error::FetchBlockError;
 use bip157::{
        chain::BlockHeaderChanges, Builder as KyotoBuilder, Client, Event as KyotoEvent,
        HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Package, Requester, TrustedPeer,
@@ -122,7 +123,7 @@ pub struct CbfChainSource {

 #[derive(Debug)]
 enum ChainOp {
-       ConnectFull { block: IndexedBlock },
+       ConnectFull { block: tokio::sync::oneshot::Receiver<Result<IndexedBlock, FetchBlockError>> },
        ConnectFiltered { header: Header, height: u32 },
        Disconnect { fork_point: BlockLocator },
        Synced { tip_height: u32 },
@@ -146,7 +147,8 @@ impl BlockApplicator {
        async fn run(mut self) {
                while let Some(op) = self.ops_rx.recv().await {
                        match op {
-                               ChainOp::ConnectFull { block: ib } => {
+                               ChainOp::ConnectFull { block: recv } => {
+                                       let ib = recv.await.unwrap().unwrap();
                                        if ib.height != self.next_height {
                                                log_debug!(
                                                        self.logger,
@@ -611,63 +613,19 @@ impl CbfChainSource {
                                        let matched = indexed_filter.contains_any(all_scripts.iter());

                                        let chop: ChainOp = if matched {
-                                               let mut attempt = 0;
-                                               let block = loop {
-                                                       attempt += 1;
-                                                       let handle = match requester.request_block(block_hash) {
-                                                               Ok(handle) => handle,
-                                                               Err(_) => {
-                                                                       log_error!(
+                                               let handle = match requester.request_block(block_hash) {
+                                                       Ok(handle) => handle,
+                                                       Err(_) => {
+                                                               log_error!(
                                                                                logger,
                                                                                "Failed to obtain receiver for matched CBF block {}; node is stopped",
                                                                                block_hash
                                                                        );
-                                                                       let _ =
-                                                                               ops_tx.send(ChainOp::Failed { error: Error::NotRunning });
-                                                                       return;
-                                                               },
-                                                       };
-
-                                                       // Bound the download so an unresponsive peer can't park the fetch forever,
-                                                       // then flatten the three error layers (timeout / receiver dropped / fetch
-                                                       // error) into a single reason so the retry-or-fail decision is written once.
-                                                       let fetched = tokio::time::timeout(
-                                                               Duration::from_secs(CBF_BLOCK_FETCH_TIMEOUT_SECS),
-                                                               handle,
-                                                       )
-                                                       .await
-                                                       .map_err(|_| {
-                                                               format!("timed out after {}s", CBF_BLOCK_FETCH_TIMEOUT_SECS)
-                                                       })
-                                                       .and_then(|recv| recv.map_err(|_| "receiver was dropped".to_string()))
-                                                       .and_then(|fetch| fetch.map_err(|e| format!("failed: {:?}", e)));
-
-                                                       match fetched {
-                                                               Ok(block) => break block,
-                                                               Err(reason) if attempt < CBF_BLOCK_FETCH_RETRIES => {
-                                                                       log_debug!(
-                                                                               logger,
-                                                                               "CBF block fetch for {} {} on attempt {}; retrying",
-                                                                               block_hash,
-                                                                               reason,
-                                                                               attempt
-                                                                       );
-                                                               },
-                                                               Err(reason) => {
-                                                                       log_error!(
-                                                                               logger,
-                                                                               "CBF block fetch for {} {} after {} attempts; giving up",
-                                                                               block_hash,
-                                                                               reason,
-                                                                               CBF_BLOCK_FETCH_RETRIES
-                                                                       );
-                                                                       let _ =
-                                                                               ops_tx.send(ChainOp::Failed { error: Error::TxSyncFailed });
-                                                                       return;
-                                                               },
-                                                       }
+                                                               let _ = ops_tx.send(ChainOp::Failed { error: Error::NotRunning });
+                                                               return;
+                                                       },
                                                };
-                                               ChainOp::ConnectFull { block }
+                                               ChainOp::ConnectFull { block: handle }
                                        } else {
                                                ChainOp::ConnectFiltered {
                                                        header: indexed_filter.header(),

@randomlogin randomlogin Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From readability perspective it makes sense.

My concern here is in that case we could process many new filters before we really got awaited block (e.g. during initial sync). Awaited block might reveal new scripts and therefore when we match filters we might get a false negative result.

Right now we wait for the block to be downloaded (though not applied) before the next filter is matched. Which is not perfect, but okay. It seems we'll need to synchronize kyoto's producer and block applicator's consumer then.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, yes that's right. Perhaps not worth the refactor at this point then

@randomlogin

randomlogin commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Transaction broadcast:

All other chain sources have access to mempool and can reliably tell if a tx has been accepted to mempool and broadcast. This is not the case with CBF, thus we need to manually mark the transaction as broadcast and then in case it does not confirm in STALE_BROADCAST_EVICTION_SECS amount of time (2 weeks, same time interval as in Bitcoin Core), we consider it evicted. This is done to prevent double-spend attempt by ourselves (e.g. during channel open). It also means UTXOs would be falsely marked as spent in case the tx is not accepted into mempool.

This is implemented in commit 6e307bd.


Right now all CBF-compatible tests pass.

Tests genuinely incompatible with CBF due to mempool reliance:

  • onchain_send_receive (receiver node expects to see unconfirmed tx, which is not possible)
  • onchain_fee_bump_rbf
  • onchain_fee_bump_rbf_respects_anchor_reserve
  • rbf_splice_channel
  • rbf_splice_channel_original_candidate_confirms

To avoid messing around with CI, these tests succeed early when run on CBF chain source.


@tnull @rustaceanrob @febyeji I would appreicate if you review it and I'd be happy to solve any found problems.
Also let me know if this PR is too big. I can split it in several ones (some changes of code in this PR are prerequisites for CBF chain source and can be done as distinct PRs).

@febyeji febyeji left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed a few potential issues below. Could you take a look and let me know if I’m missing anything?

  1. Premature funding broadcast: ChainTipChanged can broadcast a newly created funding transaction before funding_signed arrives. Input reservation needs to be separate from broadcast eligibility.

  2. Offline reorg on restart: Replay skips replacement blocks below the persisted tip, so connecting the next block can trigger LDK’s parent-hash assertion. The persisted chain needs reconciliation before replay.

  3. Different listener heights: Replaying from the lowest height sends old blocks to listeners already ahead, triggering a chain-order assertion. Each listener needs synchronization from its own tip.

Comment thread src/wallet/mod.rs Outdated
Comment thread src/chain/mod.rs
#[cfg(feature = "chain-cbf")]
ChainSourceKind::Cbf { .. } => false,
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
}
}
pub(crate) fn is_cbf(&self) -> bool {
match &self.kind {
#[cfg(feature = "chain-cbf")]
ChainSourceKind::Cbf(_) => true,
_ => false,
}
}

Comment thread src/wallet/mod.rs
| TransactionType::InteractiveFunding { .. }
)
);
let is_stale = !is_funding

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please restrict this fallback to CBF. The shared handler currently also expires transactions recently observed in Esplora or Electrum's mempool.

Suggested change
let is_stale = !is_funding
let is_stale = self.chain_source.is_cbf()
&& !is_funding

Comment thread src/chain/mod.rs
@randomlogin

Copy link
Copy Markdown
Contributor

I've started to test it in the wild (on mutinynet with ldk-server) and encountered this problem:

3. Different listener heights: Replaying from the lowest height sends old blocks to listeners already ahead, triggering a chain-order assertion. Each listener needs synchronization from its own tip.

Working on that.

I'll update the PR with new commits with all the suggestions.

@randomlogin
randomlogin force-pushed the add-cbf-chain-source branch 3 times, most recently from f3e23f7 to d15a490 Compare October 7, 2026 14:43
randomlogin and others added 3 commits October 13, 2026 22:54
`ChainListener` fans out LDK's `Listen` callbacks to the on-chain
wallet, `ChannelManager`, `ChainMonitor` and `OutputSweeper`. It is not
specific to the bitcoind backend, so any other block-driven chain source
needs the same type.

Move it, unchanged, next to `ChainSource` in `chain/mod.rs` so that
other chain sources can reuse it without reaching into the bitcoind
module. It stays gated on `chain-bitcoind`, its only user for now.

No functional change.

Co-authored-by: febyeji <yeji.han@sf.snu.ac.kr>
This is a prerequisite PR for compact block filter node.
Contrary to all other chain sources, CBF chain source in tests is
different from the node who mines blocks. That means we need to ensure
the tx from chain source has been broadcast. In the same fashion we
ensure that after the block has mined we received it from the mining
node.

Tests assumed that a node observes a freshly mined block as soon as
`sync_wallets` returns.

Make `generate_blocks_and_wait` and `premine_and_distribute_funds`
return the new chain height, add `wait_for_node_tip` to poll until a node
reports at least that height (syncing on every try, for pull-based
sources), and use it after mining in the tests.

Other changes:
- Let `exponential_backoff_poll` return `None` instead of panicking when
it runs out of tries, and `expect` at the call sites.
- Return a `Result` from the on-chain tx type assertions, so a failure is
reported at the call site.
- Wait for the sweep transaction to reach the mempool before mining it,
and wait for the transaction before mining in
`unified_send_receive_bip21_uri`, so the blocks are guaranteed to
include it.
- In `lsps2_client_trusts_lsp`, wait for the client's tip, which the
assertions inspect, rather than the payer's.

Co-authored-by: febyeji <yeji.han@sf.snu.ac.kr>
Electrum fee estimation is used both by cbf chain and electrum chain
sources, thus we make the function a free one.

`ElectrumRuntimeClient::get_fee_rate_cache_update` only needs the
runtime, the Electrum client, the network, the update timeout and a
logger. Turn it into the free function
`get_electrum_fee_rate_cache_update` so the estimation can be run
without an `ElectrumRuntimeClient`.

The client no longer reads its `config` field after this, so drop the
field and the argument threaded through `start`/`new`.

No functional change.

Co-authored-by: febyeji <yeji.han@sf.snu.ac.kr>
randomlogin and others added 8 commits October 13, 2026 23:11
* Add CBF chain source stubs for starting

Add stub methods/functions, add basic build and start of the CBF chain
source as well as basic struct containing the fields which undoubtedly
are needed.

* Populate revealed spks for CBF

Ask wallet for revealed spks, register them. Implement `Listen` trait
and add register_script method as well as implementation of registered
scripts/outputs.

Co-authored-by: febyeji <yeji.han@sf.snu.ac.kr>
Implement `process_kyoto_events` and `ChainOp`.

Co-authored-by: febyeji <yeji.han@sf.snu.ac.kr>
Add `CbfFeeSourceConfig` and fee rate estimation for the CBF chain
source. By default fee rates are derived from the coinbase outputs of
recent blocks; optionally they are delegated to an Esplora or Electrum
server. Expose the choice through `Builder::set_chain_source_cbf`,
including the UniFFI builder.

Co-authored-by: febyeji <yeji.han@sf.snu.ac.kr>
Co-authored-by: febyeji <yeji.han@sf.snu.ac.kr>
Also make `sync_wallets` wait until the applied tip reaches the chain
tip, fix block fetching, and use the block header from the
`IndexedFilter` event.

On the test side, add CBF as a test chain source (bitcoind is started
with P2P and compact block filters enabled) and wait for tx gossip
before mining in some of the tests.
Previously the source of truth was the chain source: we can understand
whether the tx has been broadcast by querying its mempool. This does not
work with compact block filter node which does not have a mempool.

With CBF we can only preemptively mark tx as broadcast and mark
corresponding tx outputs as spent (to prevent double spend attempt or
allow 0conf channels).

This commit implements it via calling `apply_mempool_txs` with a tx to
be broadcast (in `send_to_address`, `bump_fee_rbf` and
`create_funding_transaction`).

Stale transaction which is not confirmed during next 2 weeks (2016
blocks, same value as for bitcoin core) is evicted. To properly apply
`WalletEvent`s we need to use the block time, not the current time.

Bugfixes: remove persister lock

Used Claude to write code
Also retry the assertions on the on-chain tx types: a node's own
broadcast-queue processing can lag behind what electrs/bitcoind already
see, so asserting once immediately is flaky.
A node might go offline on a reorganized block, which would make
syncing stall after a restart.

This commit adds a preprocessing step which runs once before the events
from kyoto are processed.

During the preprocessing step we check whether we have forked and
disconnect blocks accordingly. Add a test for a deep reorg that happens
while the node is offline.

Still there might be a problem if several of chain monitors diverged and
live on different forks, but other chain sources (bitcoind) ignore that
and that perhaps requires a fix upstream.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants